package org.durandj.ystream.server.network;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.security.InvalidParameterException;
import java.util.Collection;

import org.durandj.ystream.server.Server;
import org.durandj.ystream.server.message.InvalidMessageContentsException;
import org.durandj.ystream.server.message.Message;
import org.durandj.ystream.server.message.MessageHandler;
import org.durandj.ystream.server.message.MessageID;
import org.durandj.ystream.server.network.packetHandlers.NOPPacketHandler;
import org.durandj.ystream.server.network.packetHandlers.PingPacketHandler;
import org.durandj.ystream.server.network.packetHandlers.ServerDisconnectPacketHandler;
import org.durandj.ystream.server.util.ByteBufferUtils;
import org.durandj.ystream.server.util.InetAddressUtils;
import org.durandj.ystream.server.util.Timer;

/**
 * Listens to the specified port for incoming messages
 * from clients and report it to any packet listeners.
 * 
 * @author durandj
 * @version 1.0
 *
 */
public class NetworkDaemon extends MessageHandler
{
	/**
	 * This defines how often the server will go out and check
	 * the ping time between it and the client. This time is
	 * given in milliseconds.
	 */
	private static final long PING_INTERVAL = 30000;
	
	/**
	 * The socket to monitor.
	 */
	private ServerSocketChannel serverChannel;
	
	/**
	 * The connection selector.
	 */
	private Selector selector;
	
	/**
	 * The packet manager.
	 */
	private PacketManager packetManager;
	
	/**
	 * The flag for keeping the daemon alive.
	 */
	private boolean isAlive;
	
	/**
	 * The thread of execution.
	 */
	private Thread thread;
	
	/**
	 * Creates a server network daemon that watches
	 * the specified port.
	 * 
	 * @param port - the server port
	 * @throws IOException when something goes wrong opening the port
	 */
	public NetworkDaemon(int port) throws IOException
	{
		if(port <= 0)
			throw new InvalidParameterException("Must give a valid port.");
		
		this.serverChannel = ServerSocketChannel.open();
		this.selector = Selector.open();
		this.serverChannel.configureBlocking(false);
		this.serverChannel.socket().bind(new InetSocketAddress("127.0.0.1", port));
		this.serverChannel.register(this.selector, SelectionKey.OP_ACCEPT);
		this.packetManager = new PacketManager(128);
		this.isAlive = true;
		
		setupHandlers();
		
		this.thread = new Thread(new Runnable() {
			@Override
			public void run()
			{
				// The time of the next ping test of the clients.
				long pingTime = System.currentTimeMillis() + PING_INTERVAL;
				
				// We need to make sure some classes are up and running
				// before we do any work here so lets wait a moment.
				while(Server.getServer() == null)
					new Timer(100);
				
				while(isAlive)
				{
					// First let's check to see if there are any pending messages and
					// handle one of those.
					if(!noMessages())
						handleMessage(nextMessage());
					
					// Now that we've done some local work let's check on all
					// of the currently connected clients and see if we have
					// any messages from them. We can also check for new clients
					// that want to connect to the server.
					try
					{
						// Lets get socket channels that have something happening to them.
						selector.selectNow();
					}
					catch(IOException e1)
					{
						// TODO Auto-generated catch block
						e1.printStackTrace();
						continue;
					}
					
					for(SelectionKey key : selector.selectedKeys())
					{
						if(key.isAcceptable())
							accept();
						
						if(key.isReadable())
							getPacket(key);
					}
					
					// Check if it's time to ping the clients again.
					if(System.currentTimeMillis() >= pingTime)
					{
						pingClients();
						
						// Determine the time for the next ping test.
						pingTime = System.currentTimeMillis() + PING_INTERVAL;
					}
					
					// Let's stall quick so we don't blow up the computer.
					new Timer(100);
				}
				
				// Let's do some cleanup work on the daemon.
				try
				{
					selector.close();
					serverChannel.close();
				}
				catch(IOException e)
				{}
				
				packetManager.stopHandlers();
			}
		}, "Network Daemon");
		thread.start();
	}
	
	/**
	 * Checks if the network daemon is running.
	 * 
	 * @return is the network daemon alive?
	 */
	public boolean isAlive()
	{
		return isAlive || thread.isAlive();
	}
	
	/**
	 * Adds the packet handlers and their associated opcode.
	 * This should be modified whenever a new packet handler is
	 * added to the server.
	 */
	private void setupHandlers()
	{
		packetManager.setPacketHandler(Opcode.NOP, new NOPPacketHandler());
		packetManager.setPacketHandler(Opcode.SERVER_DISCONNECT, new ServerDisconnectPacketHandler());
		packetManager.setPacketHandler(Opcode.PING, new PingPacketHandler());
	}
	
	/**
	 * Called when a client wants to connect to the server.
	 */
	private void accept()
	{
		try
		{
			SocketChannel newConnection = serverChannel.accept();
			if(newConnection == null)
				return;
			
			// If an exception is not thrown by the server socket's
			// accept method then a new client was found so let's
			// start setting up their connection to the server and
			// get them registered.
			Client client = null;
			try
			{
				client = new Client(newConnection);
				SelectionKey key = newConnection.register(selector, SelectionKey.OP_READ);
				
				Server.getServer().getClientManager().addClient(key, client);
				log.info("New client connected.\n" +
						 "Client Address: " + InetAddressUtils.getStringAddress(client.getClientAddress()));
			}
			catch(UnknownHostException e)
			{
				log.warning("Could not establish connection to client!");
			}
			catch(IOException e)
			{
				log.warning("An unknown problem occured with the client connection.\n" +
				            e.getMessage());
			}
			// TODO: More will probably need to be done here.
		}
		catch(SocketTimeoutException e) {}
		catch(IOException e)
		{
			log.info("An error occured in the server socket!");
		}
	}
	
	/**
	 * Gets a packet from the specified client and sends the packet to the appropriate packet handler.
	 * 
	 * @param key - the selection key for the client
	 * @return if a packet was received
	 */
	private boolean getPacket(SelectionKey key)
	{
		if(key == null)
			throw new NullPointerException("Must give a valid key.");
		
		Client client = Server.getServer().getClientManager().getClient(key);
		Packet packet = getPacket(client);
		
		if(packet == null)
			return false;
		
		packetManager.handlePacket(packet);
		
		// Since we've received a sign that the client is alive lets reset their watchdog.
		client.feedWatchdog();
		
		return true;
	}
	
	/**
	 * Gets a packet from the specified client and sends the packet to the appropriate packet handler.
	 * 
	 * @param client - the client to get the packet from
	 * @return if a packet was received
	 */
	private Packet getPacket(Client client)
	{
		if(client == null)
			return null;
		
		if(!client.getChannel().isOpen() || client.isDirty())
			return null;
		
		// Let's try reading all the bytes needed for a full packet.
		// TODO: This isn't necessary.
		ByteBuffer buf = ByteBuffer.allocate(Packet.MAX_PACKET_SIZE);
		
		try
		{
			int bytesread;
			if((bytesread = client.getChannel().read(buf)) == -1 || bytesread == 0)
				return null;
			buf.rewind();
		}
		catch(IOException e)
		{
			String address = InetAddressUtils.getStringAddress(client.getClientAddress());
			ByteBuffer contents = ByteBufferUtils.stringToByteBuffer(address);
			Server.getServer().getClientManager().queueMessage(new Message(Message.Priority.HIGH,
			                                                               MessageID.DISCONNECT_CLIENT,
			                                                               this,
			                                                               contents));
			return null;
		}
		
		// We can now assemble the packet.
		// First we get the opcode.
		int opcode = buf.getInt();
		if(opcode < 0 || opcode >= packetManager.getOpcodeNumber())
		{
			log.info("Invalid packet data from " + InetAddressUtils.getStringAddress(client.getClientAddress()));
			return null;
		}
		
		// Now we pull out the priority of the packet.
		int nPriority = buf.getInt();
		if(nPriority < 0 || nPriority > 2)
		{
			log.info("Invalid packet data from " + InetAddressUtils.getStringAddress(client.getClientAddress()));
			return null;
		}
		
		Packet.Priority priority = null;
		switch(nPriority)
		{
		case 0:
			priority = Packet.Priority.LOW;
			break;
		case 1:
			priority = Packet.Priority.MEDIUM;
			break;
		case 2:
			priority = Packet.Priority.HIGH;
			break;
		}
		
		// Now we can see how much data is stored in the packet.
		int size = buf.getInt();
		if(size < 0)
		{
			log.info("Invalid packet data from " + InetAddressUtils.getStringAddress(client.getClientAddress()));
			return null;
		}
		
		
		// Now we can get the contents of the packet.
		byte tempbuf[] = new byte[Packet.MAX_PACKET_DATA_SIZE];
		buf.slice().get(tempbuf);
		ByteBuffer contents = ByteBuffer.wrap(tempbuf);
		
		// We can now assemble the packet and send it out to the packet handlers.
		return new Packet(priority,
		                  opcode,
		                  "127.0.0.1",
		                  InetAddressUtils.getStringAddress(client.getClientAddress()),
		                  contents);
	}
	
	/**
	 * Pings all the clients to get the latency of the connections.
	 */
	private void pingClients()
	{
		log.info("Pinging clients...");
		
		Collection<Client> clients = Server.getServer().getClientManager().getClients();
		for(int i = clients.size() - 1; i >= 0; i--)
		{
			Client client = (Client)clients.toArray()[i];
			if(client.isDirty())
				continue;
			
			// Make the ping data and packet.
			ByteBuffer buf = ByteBuffer.allocate(8);
			buf.putLong(System.currentTimeMillis());
			buf.rewind();
			
			Packet ping = new Packet(Packet.Priority.LOW,
			                         Opcode.PING,
			                         InetAddressUtils.getStringAddress(client.getClientAddress()),
			                         InetAddressUtils.getStringAddress(getServerAddress()),
			                         buf);
			
			client.getPacketSender().sendPacket(ping);
		}
		
		log.info("Finished pinging clients!");
	}
	
	/**
	 * Gets the server's address.
	 * 
	 * @return server address
	 */
	public InetAddress getServerAddress()
	{
		return serverChannel.socket().getInetAddress();
	}
	
	/**
	 * Gets the selection key for a specified client.
	 * 
	 * @return the selection key for the client or null if it does not have a selection key
	 */
	public SelectionKey getSelectionKey(Client client)
	{
		if(client == null)
			throw new NullPointerException("Must give a valid client reference.");
		
		return client.getChannel().keyFor(selector);
	}

	/* (non-Javadoc)
	 * @see org.durandj.ystream.server.message.MessageHandler#handleMessage(org.durandj.ystream.server.message.Message)
	 */
	@Override
	protected void handleMessage(Message msg)
	{
		switch(msg.getID())
		{
		case KILL:
			isAlive = false;
			break;
			
		case DISCONNECT_CLIENT:
		{
			// To disconnect a client we need to know what client we are supposed to disconnect.
			if(msg.getContents() == null)
				throw new InvalidMessageContentsException("Must give a client address to be able to disconnect.");
			
			ByteBuffer buf = ByteBuffer.wrap(msg.getContents().array());
			
			// There's some kind of message contents so we need to verify that the message
			// has a string.
			String address = ByteBufferUtils.getStringFromByteBuffer(buf);
			
			if(address == null || address.isEmpty())
				throw new InvalidMessageContentsException("Must give a client address to be able to disconnect.");
			
			// Now that we have a string let's make sure that the string gives a valid address
			// that refers to a connected client.
			InetAddress inetaddress = null;
			try
			{
				inetaddress = InetAddress.getByName(address);
			}
			catch(Exception e)
			{
				throw new InvalidMessageContentsException("Must give a client address to be able to disconnect.");
			}
			
			Client client = Server.getServer().getClientManager().getClient(inetaddress);
			if(client == null)
				throw new InvalidMessageContentsException("Must give a client address to be able to disconnect.");
			
			// Now that we have a client reference we can proceed to send the disconnect signal.
			client.getPacketSender().sendPacket(new Packet(Packet.Priority.MEDIUM,
			                                               Opcode.CLIENT_DISCONNECT,
			                                               InetAddressUtils.getStringAddress(client.getClientAddress()),
			                                               InetAddressUtils.getStringAddress(getServerAddress())));
			
			Server.getServer().getClientManager().queueMessage(msg);
		}
			break;
			
		default:
			log.warning("Unhandled message!\n" +
						"\tID: " + msg.getID() + "\n" +
						"\tSender: " + msg.getSender().getClass().getSimpleName() + "\n");
			break;
		}
	}
}
